Examinando datos con pandas
In [85]:
speaker = {'name':'Mai Giménez',
'twitter': '@adahopper',
'weapons': ['python', 'bash','C++ ']}
print('\n'.join(["{}: {}".format(k, v) for k,v in speaker.items()]))
In [2]:
from IPython.display import Image
Image(filename='marvel_logo.jpg')
Out[2]:
Marvel, es una editorial de cómics estadounidense fundada por Martin Goodman en 1939. Aunque la marvel tal y como hoy la conocemos data de 1961 con la publicación de Los cuatro fantásticos y otras historias de superhéroes creadas por Stan Lee, Jack Kirbi, Steve Ditko,...
Marvel publica a personajes archiconocidos como:
[Wikipedia]
¡Y todos estos datos son nuestros!
In [3]:
from IPython.core.display import HTML
MARVEL_DEV_SITE = "http://developer.marvel.com/"
HTML("<iframe src={} width=800 height=600></iframe>".format(MARVEL_DEV_SITE))
Out[3]:
Pandas es una librería de código abierto, con licencia BSD, que permite trabajar eficientemente analizando datos en python.
A pandas se le da bien:
In [4]:
PANDAS_DEV_SITE = "http://pandas.pydata.org/"
HTML("<iframe src={} width=800 height=600></iframe>".format(PANDAS_DEV_SITE))
Out[4]:
In [5]:
import pandas as pd
import sys
import datetime as dt
import numpy as np
import matplotlib.pyplot as plt
import matplotlib
%matplotlib inline
print("Versión de Python: ", sys.version)
print("Versión de Pandas: ", pd.version.short_version)
print("Versión de Numpy: ", np.version.short_version)
print("Versión de Matplotlib: ", matplotlib.__version__)
Marvel sólo nos deja buscar hasta 100 personajes/cómics cada vez. Tenemos una libería para acceder directamente a la api de Marvel en python desarrollada por Garrett Pennington pymarvel en python 2 y está portada a python 3 en pymarvel3
Lo primero que deberíamos hacer es recoger información de las web y almacenarnoslas. Pero, a alguien más se le ha ocurrido eso, y no vamos a reinventar la rueda. @asamiller ha desarrollado una app en node.js que explora la api de marvel y almacena los datos usando Orches Orchestrate. Tenemos el código disponible en github.
In [6]:
from os.path import join, abspath, isfile
from os import listdir, getcwd, pardir
MARVELOUSDB_PATH = join(abspath(join(getcwd(), pardir)),"marvelousdb","data")
MARVELOUSDB_CHARACTERS = join(MARVELOUSDB_PATH,"characters")
MARVELOUSDB_COMICS = join(MARVELOUSDB_PATH,"comics")
In [7]:
characters_json_db = [join(MARVELOUSDB_CHARACTERS,json_file) for json_file in listdir(MARVELOUSDB_CHARACTERS)]
comics_json_db = [join(MARVELOUSDB_COMICS,json_file) for json_file in listdir(MARVELOUSDB_COMICS)]
print("En MarvelousDB tenemos un backup de {} personajes y {} cómics".format(len(characters_json_db),
len(comics_json_db)))
Un DataFrame es una estructura de 2 dimensiones con datos etiqueatados en columnas. Los datos que componen un dataframe pueden ser de distintos tipos. Piensa en un dataframe como si fuera una hoja de cáculo o una tabla SQL.
Puedes formar un dataframe usando:
Al crear un dataframe, también puedes indicar los índices (etiquetas para las filas) y las columnas. Si no pasamos estas etiquetas como argumentos pandas creará un dataframe usando el sentido común.
En nuestro caso, leeremos todos los ficheros json y crearemos un DataFrame. Como tenemos información jerárquica en los ficheros json necesitamos normalizar los datos, pero pandas tiene funciones que lo hacen por nosotros.
In [8]:
import json
In [9]:
json_to_dataframe = []
for json_file in characters_json_db:
with open(json_file, 'r') as jf:
json_character = json.loads(''.join(jf.readlines()))
json_plain = pd.io.json.json_normalize(json_character)
json_to_dataframe.append(json_plain)
characters_df = pd.concat(json_to_dataframe)
In [10]:
df = pd.concat([pd.io.json.json_normalize(json.loads(''.join(open(json_file,'r').readlines())))
for json_file in characters_json_db])
Podemos realizar operaciones lógica sobre todos los elementos de un DataFrame, son operaciones vectoiales.
In [12]:
all(df == characters_df)
Out[12]:
In [13]:
comics_df = pd.concat([pd.io.json.json_normalize(json.loads(''.join(open(json_file,'r').readlines())))
for json_file in comics_json_db if isfile(json_file)])
¿Y que pinta tiene un DataFrame?
In [14]:
characters_df.head()
Out[14]:
In [15]:
comics_df.tail()
Out[15]:
Los DataFrames de pandas están implementados basandose en numpy, de modo que si queremos saber la longitud que tiene un Dataframe es exáctamente igual que en numpy, fácil ¿verdad?
In [16]:
characters_df.shape
Out[16]:
In [17]:
comics_df.shape
Out[17]:
Vamos a ver que podemos saber de los personajes
In [18]:
', '.join(characters_df.columns.values)
Out[18]:
En realidad no deberíamos lanzar las campanas al vuelo porque spoiler muchos de los campos están vacios
In [19]:
characters_df.dropna()
Out[19]:
¿Y qué pasa con los cómics?
In [20]:
comics_df.dropna().shape
Out[20]:
Con una simple instrucción somos capaces de tratar con todos los nulos de un dataframe.
Stanley Martin Lieber, más conocido como Stan Lee, nació el 28 de diciembre de 1922 en la ciudad de Nueva York. Es un guionista y editor de cómics estadounidense, creador de personajes notables por su complejidad y su realismo.
Es el cocreador, junto a dibujantes como Steve Ditko o Jack Kirby, de superhéroes como Los 4 Fantásticos, Spider-Man, Hulk, Iron Man, Thor, The Avengers, Daredevil, Doctor Strange, X-Men y muchos otros personajes, expandiendo Marvel Comics, llevándola de una pequeña casa publicitaria a una gran corporación multimedia. Todavía hoy, los cómics Marvel se distinguen por indicar siempre «Stan Lee presenta» en los rótulos de presentación. También tiene un programa en History Channel en donde busca super humanos reales. [Wikipedia]
Vamos a ver cuantos personajes ha creado. Y quien ostenta el top de creadores según la api de Marvel.
Series es un array de 1 dimensión etiquetado. Como una tabla con una única columna. Puede almacenar cualquier tipo de datos:
Se etiquetan en función del índice, si el índice que le pasamos son fechas se creará una instancie de TimeSerie, esta bien pensado, ¿verdad?
Cuando hacemos una selección de 1 columna en un Dataframe creamos una Serie.
In [21]:
#Stan Lee
creators_serie = characters_df['wiki.creators'].dropna()
creators_serie.describe()
Out[21]:
In [22]:
#Renombramos la serie y el índice
creators_serie.name = 'Creadorers de personajes'
creators_serie.index.name = 'creators'
# Podemos usar head o como estamos sobre series también podemos coger una porción de la lista
# creators_serie.head()
creators_serie[:20]
Out[22]:
In [23]:
default_string = creators_serie != "this has not been updated yet"
default_string.head()
#creators_serie[ creators_serie != "this has not been updated yet" ]
Out[23]:
In [24]:
empty_string = creators_serie != ""
empty_string[:10]
Out[24]:
In [25]:
default_string and empty_string
A pesar de que la palabra reservada and podríamos creer que funcionaría para unir series no funciona porque la operación no se aplica elemento a elemento. Pero pandas sabe que esto nos podría hacer falta y tenemos operadores que funcionan para elementos (& (and), | (or), ~(not))
In [26]:
creators_mask = default_string & empty_string
creators_mask[:10]
Out[26]:
In [27]:
creators_serie[creators_mask].head()
Out[27]:
Aquí ya tenemos buena parte de la información que queremos, pero vamos a separar los autores que trabajan junto para poder contar cuantos personajes a creado cada uno.
In [28]:
import re
creators = [re.split('&|and|,', line) for line in creators_serie[creators_mask]]
clean_cretors = pd.Series([c for creator in creators for c in creator])
clean_cretors.head()
Out[28]:
In [29]:
clean_cretors.value_counts().head()
Out[29]:
¡Vaya Stan Lee parece que Chris Claremont te gana!
Obviamente es un problema de falta de datos. Por eso debemos ser muy cuidadosos con la confianza que tenemos en nuestros resultados. Un corpus con errores nos llevará a conclusiones erróneas, hay que ser conscientes de esto.
Marvel no distingue personajes de grupos de personajes. Es decir, "Los vengadores" es un personaje igual que podría serlo "Iron Man", pero tenemos un campo en la wiki que nos permite diferenciar grupos de personajes: "Former members". Así que vamos a quedarnos solo con los personajes.</br>
Lo normal es que quisieramos eliminar las filas que contienen nulos, y pandas tiene implementada una función para ello dropna, que ya hemos visto. Pero lo que queremos es quedarnos con aquellas filas en cuya columna current_members tengamos un nulo, porque si no hay miembros es porque es un personaje.
In [30]:
characters_df.dropna(subset=['wiki.current_members'])['name']
Out[30]:
In [31]:
%timeit (~characters_df['wiki.current_members'].isnull())
import numpy as np
%timeit (np.invert(characters_df['wiki.current_members'].isnull()))
In [32]:
not_groups_mask = characters_df['wiki.current_members'].isnull()
not_groups_mask.head()
Out[32]:
In [33]:
characters_df=characters_df[not_groups_mask]
In [34]:
characters_df[:3]
Out[34]:
Vamos a limpiar lo datos, quedarnos con los campos que nos puedan ser útililes y indexar el dataframe usando el nombre del superhéroe o de la superheroína, porque pandas ha hecho lo que ha podido pero los números no son muy intuitivos.
In [35]:
# Agrupamos los datos para tener claro con que queremos trabajar
physical_data = ['wiki.hair', 'wiki.weight', 'wiki.height', 'wiki.eyes']
cultural_data = ['wiki.education', 'wiki.citizenship', 'wiki.place_of_birth', 'wiki.occupation']
personal_data = ['wiki.bio', 'wiki.bio_text', 'wiki.categories']
data_keys = (physical_data + cultural_data + personal_data + ['name','comics.available'])
¿Os acordáis de dropna()? Pues puede hacer mucho más.
In [36]:
clean_df = characters_df.dropna(subset = data_keys)
clean_df = clean_df[data_keys].set_index('name')
clean_df.shape
Out[36]:
Por ejemplo, sería muy interesante saber cuantas razas están representadas en los cómics de Marvel, y existe un campo skin en la wiki, pero...
In [37]:
characters_df['wiki.skin'].dropna()
Out[37]:
Pero vamos a explorar lo que tenemos.
In [38]:
clean_df[personal_data].head()
Out[38]:
In [39]:
clean_df[cultural_data].head()
Out[39]:
In [40]:
clean_df[cultural_data].describe()
Out[40]:
In [41]:
clean_df[physical_data].head()
Out[41]:
¿Cómo diriáis que es físicamente el personaje típico de la marvel? (pandas lo sabe)
In [42]:
clean_df[physical_data].describe()
Out[42]:
De modo que el personaje arquetípico de la Marvel tiene el pelo negro y los ojos azules, es de EE.UU. se dedica a ser aventurero. A mí la profesión ya me gusta.
In [43]:
clean_df['comics.available'].describe()
Out[43]:
¿2575.000000? Debe ser un error, ¿no? ¿Quién es el pluriempleado?
In [44]:
clean_df[clean_df['comics.available'] == 2575.000000]
Out[44]:
No se si es un error, pero sino lo es el llorón de spiderman aparece en muchos cómics.
Antes de ponernos a jugar con los datos (más), tenemos una columna de la que se pude sacar mucho partido "wiki.categories"
In [45]:
clean_df.iloc[1]
Out[45]:
A priori no tenemos información de que personajes son hombres, mujeres o alienigenas. Pero Marvel debió intuir que nos podría interesar el papel de las mujeres en los cómics y nos incluyo una categoría: "Mujeres", que nos va a facilitar la vida un montón. Vamos a crear dos nuevas columnas en el dataframe:
In [46]:
women = clean_df['wiki.categories'].map(lambda x: 'Women' in x)
clean_df['Women'] = women
women[:5]
Out[46]:
In [47]:
# ~ Esto es una negación element-wise
print("Mujeres: #{}, hombres #{}".format(clean_df[women].shape[0],clean_df[~women].shape[0]))
Es decir, tenemos 199 personajes femeninos y 563 masculinos. Es decir solo el 26% de los personajes son femeninos.
In [48]:
villan = clean_df['wiki.categories'].map(lambda x: 'Villains' in x)
clean_df['Villan'] = villan
In [49]:
print("Villanos: #{}, Héroes #{}".format(clean_df[villan].shape[0],clean_df[~villan].shape[0]))
Los villanos también tienen mucho trabajo porque al parecer son sólo el 30'31% de los personajes.
Vamos a ver cómo se distribuyen hombres y mujeres los roles de héroes y villanos.
In [50]:
men = ~women
gender_data = {'Women':{'Heroes':0,'Villans':0},'Men':{'Heroes':0,'Villans':0}}
# Women and villans
gender_data['Women']['Villans'] = clean_df[villan & women].shape[0]
# Women and heroes
gender_data['Women']['Heroes'] = clean_df[~villan & women].shape[0]
# Men and villans
gender_data['Men']['Villans'] = clean_df[villan & men].shape[0]
# Men and heroes
gender_data['Men']['Heroes'] = clean_df[~villan & men].shape[0]
gender_data
Out[50]:
In [51]:
n_groups = 2
opacity = 0.3
men_data = (gender_data['Men']['Villans'], gender_data['Men']['Heroes'])
women_data = (gender_data['Women']['Villans'], gender_data['Women']['Heroes'])
fig, ax = plt.subplots()
index = np.arange(n_groups)
bar_width = 0.4
rects1 = plt.bar(index, men_data, bar_width,
alpha=opacity,
color='b',
label='Hombres')
rects2 = plt.bar(index + bar_width, women_data, bar_width,
alpha=opacity,
color='r',
label='Mujeres')
plt.xlabel('Rol')
plt.ylabel('Número de personajes')
plt.title('Distribución por género y roles')
plt.xticks(index + bar_width, ('Héroes', 'Villanos'))
plt.legend(loc=0, borderaxespad=1.)
plt.show()
In [52]:
comics_df.dtypes
Out[52]:
En el campo precio aun tenemos un objeto json. ¡Mal! Así no podemos analizarlo.
El tipo objeto en dtype proviene de numpy y describe un elemento de un ndarray. Cada elemento deben ser del mismo tamaño en bytes. Para un int64 y un float64 necesitamos 8 bytes, pero para una cadena la longitud total no está prefijada y lo que almacena Pandas es un puntero.
¡Pero no pasa nada! Lo que vamos a hacer es convertirlo a una serie, quedarnos únicamente con el precio impreso y arreglar esta columna del dataframe.
In [53]:
prices = comics_df.prices
In [54]:
prices_serie = prices.apply(pd.Series)
In [55]:
prices_serie[20:30]
Out[55]:
In [60]:
print_price = prices_serie[0].apply(pd.Series)['price']
In [61]:
digital_price = prices_serie[1].apply(pd.Series).price
In [62]:
digital_price.value_counts()
Out[62]:
In [63]:
digital_price.count()
Out[63]:
Sólo el 24'4% se ha editado digitalmente.
Eliminamos la columna sucia y añadimos los datos limpios.
In [64]:
#del también funcionaria del df.column_name
comics_df = comics_df.drop('price')
In [65]:
comics_df['print price'] = print_price
comics_df['digital price'] = digital_price
A las fechas les pasa exáctamente lo mismo que a los precios. Vamos a limpiar los datos (data munging again)
In [66]:
dates = comics_df.dates
dates_serie = dates.apply(pd.Series)[0].apply(pd.Series)
In [67]:
on_sale_date = dates_serie.date.astype('datetime64[ns]')
on_sale_date.head()
Out[67]:
In [68]:
comics_df['On sale Date'] = on_sale_date
In [69]:
start = comics_df['On sale Date'].min()
end = comics_df['On sale Date'].max()
yearly_range = pd.date_range(start, end, freq='365D6H')
In [70]:
comics_per_year = comics_df.groupby(on_sale_date.map(lambda x: x.year)).size()
comics_per_year.plot()
Out[70]:
In [71]:
really_old = comics_df[on_sale_date==start]
print(start)
WTF! La Marvel es muuuucho más antigua de lo que nosostros/as pensabamos.
In [72]:
really_old.dates.iloc[1]
Out[72]:
O es un problema de formato. En cualquier caso no queremos esos datos, son ruido.
In [73]:
#back_to_future_comics = comics_df[on_sale_date==end]
print(end)
back_to_future_comic = comics_df[comics_df['On sale Date'] == end]
back_to_future_comic.title
Out[73]:
In [74]:
print("Vamos a eliminar {} ficheros.".format(really_old['On sale Date'].shape[0]))
In [75]:
comics_df = comics_df[comics_df['On sale Date'] != start]
In [76]:
comics_per_year = comics_df.groupby(comics_df['On sale Date'].map(lambda x: x.year)).size()
comics_per_year.plot()
Out[76]:
Muuucho mejor.
In [77]:
comics_df = comics_df.fillna(0)
¿Nos acordamos del dropna? Pues tambíen tenemos un fillna
In [78]:
comics_group = comics_df.groupby(comics_df['On sale Date'].map(lambda x: x.year))
In [79]:
price_per_year = comics_group['print price', 'pageCount', 'digital price'].mean()
In [80]:
price_per_year
Out[80]:
In [81]:
price_per_year.plot()
Out[81]:
In [82]:
plt.figure()
with pd.plot_params.use('x_compat', True):
price_per_year['print price'].plot(color='r')
price_per_year['digital price'].plot(color='g')
In [ ]: